Skip to content

KTOR-9451 Support nested generic types#5500

Merged
bjhham merged 2 commits intorelease/3.xfrom
bjhham/nested-generics-schema
Apr 20, 2026
Merged

KTOR-9451 Support nested generic types#5500
bjhham merged 2 commits intorelease/3.xfrom
bjhham/nested-generics-schema

Conversation

@bjhham
Copy link
Copy Markdown
Contributor

@bjhham bjhham commented Mar 31, 2026

Subsystem
Server, OpenAPI (Schema)

Motivation
KTOR-9451 OpenAPI schema inference not working for custom nested generics

Solution
We were previously only checking if the supplied KType was parameterized, but this logic doesn't work for multiple levels of generics. To account for this, we need to initially traverse the type argument tree to populate a lookup of KTypes, because the generated serializers' descriptors have no way of introspecting on this.

Additionally, I noticed that generics were only supported for collection types on the reflection schema inference side, so I also fixed this.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 31, 2026

📝 Walkthrough

Walkthrough

Adds KType caching and propagation to JSON schema inference, alters descriptor recursion and title emission for parameterized types, and improves JVM reflection handling by substituting generic type arguments and resolving value-class underlying types relative to owner KType.

Changes

Cohort / File(s) Summary
Core schema inference
ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt
Introduce kTypeLookup cache, includeKType(type: KType), call cache before buildSchema, add SerialDescriptor.isParameterized(), and change default includeTitle handling for parameterized descriptors.
JVM reflect inference
ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/src/io/ktor/openapi/reflect/JsonSchemaInference.jvm.kt
Compute value-class underlying type using owner KType, add swapTypeArgs to substitute KTypeParameters with owner arguments (preserving nullability), and update property schema resolution to use substituted types.
Tests & resources
ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test-resources/schema/Response.yaml, ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test/io/ktor/openapi/reflect/AbstractSchemaInferenceTest.kt
Add expected OpenAPI schema YAML and a test exercising nested generics via new @Serializable generic types Response<T> and Page<out E> and assertion for Response<Page<Country>>.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

👍 ship!

Suggested reviewers

  • osipxd
  • e5l
  • zibet27
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'KTOR-9451 Support nested generic types' is clear, specific, and directly summarizes the main change in the changeset—implementing support for nested generic types in OpenAPI schema inference.
Description check ✅ Passed The PR description follows the required template with all sections filled out: Subsystem, Motivation (with ticket reference), and Solution explaining the technical approach for supporting nested generics.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bjhham/nested-generics-schema

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@bjhham bjhham force-pushed the bjhham/nested-generics-schema branch from e7592de to 75a8e65 Compare March 31, 2026 07:20
@bjhham bjhham force-pushed the bjhham/nested-generics-schema branch from 75a8e65 to bfd7efa Compare April 14, 2026 15:31
@bjhham bjhham force-pushed the bjhham/nested-generics-schema branch from bfd7efa to f7eb788 Compare April 14, 2026 15:40
@bjhham bjhham requested a review from osipxd April 20, 2026 08:00
@bjhham bjhham marked this pull request as ready for review April 20, 2026 08:00
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/src/io/ktor/openapi/reflect/JsonSchemaInference.jvm.kt (1)

242-250: ⚠️ Potential issue | 🔴 Critical

resolvedPropertyType is computed but never used for the actual schema — defeats the generic substitution.

buildSchemaOrRef(prop.returnType, …) is still passing the unresolved prop.returnType. For a property like Response<T>.data whose returnType.classifier is a KTypeParameter, buildSchemaInternal falls through the classifier as? KClass<*> cast and returns a bare JsonSchema(type = JsonType.OBJECT) — so the expected nested structure in Response.yaml (data → items/total → Country) won't be produced through the reflection path. The isNullable fix alone isn't enough.

🐛 Proposed fix
             val propertyName = adapter.getName(prop)
             val resolvedPropertyType = swapTypeArgs(prop.returnType, type)
             val propertyIsNullable = adapter.isNullable(resolvedPropertyType)

-            properties[propertyName] = buildSchemaOrRef(prop.returnType, visiting, prop.annotations)
+            properties[propertyName] = buildSchemaOrRef(resolvedPropertyType, visiting, prop.annotations)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/src/io/ktor/openapi/reflect/JsonSchemaInference.jvm.kt`
around lines 242 - 250, The code calculates resolvedPropertyType via
swapTypeArgs but still calls buildSchemaOrRef(prop.returnType…), so generic type
substitutions are ignored; change the call to use the substituted type
(resolvedPropertyType) and use that same resolved type when checking nullability
and when passing annotations/context into buildSchemaInternal (i.e., replace
occurrences of prop.returnType with resolvedPropertyType for schema creation),
ensuring functions like buildSchemaOrRef, buildSchemaInternal, swapTypeArgs, and
adapter.isNullable operate on the resolvedPropertyType so Reflection produces
the correct nested schema for generic properties (e.g., Response<T>.data).
ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt (1)

77-103: ⚠️ Potential issue | 🟡 Minor

Update ABI signatures after verifying Default getter behavior is intentional.

The implementation creates a fresh KotlinxSerializerJsonSchemaInference instance on every access to Default (line 77-78), which differs from a cached singleton. Since each instance has its own kTypeLookup, there is no cross-call leakage—each top-level buildSchema call works against an independent cache. However, this means:

  1. KotlinxJsonSchemaInference (line 42) and SerialDescriptor.buildJsonSchema(...) (line 349) produce ephemeral instances whose kTypeLookup stays empty, making isParameterized() effectively a no-op in those paths. If this is intentional, add a brief comment clarifying the scope; if not, consider storing the instance or passing kTypeLookup as state.

  2. The key strategy type.toString().substringBefore('<') (line 94) is fragile—KType.toString() is not a contractually stable identity. While the current code is self-correcting (line 103 only triggers on entries with non-empty arguments), document the invariant for clarity. Alternatively, use (type.classifier as? KClass<*>)?.qualifiedName where available and fall back to toString() only for web/JS.

Run ./gradlew :ktor-openapi-schema:updateLegacyAbi and checkLegacyAbi to update the /api/ signatures—this is a new public property (getter) and requires ABI tracking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt`
around lines 77 - 103, The Default getter currently returns a new
KotlinxSerializerJsonSchemaInference each call which yields ephemeral
kTypeLookup caches (affecting buildSchema, KotlinxJsonSchemaInference and
SerialDescriptor.buildJsonSchema paths) — either make Default return a cached
singleton instance of KotlinxSerializerJsonSchemaInference (so kTypeLookup is
shared) or explicitly document the intended per-call scope with a comment; while
here, harden includeKType’s key strategy by using (type.classifier as?
KClass<*>)?.qualifiedName ?: type.toString().substringBefore('<') in
includeKType and ensure isParameterized reads from the intended shared
kTypeLookup; after choosing the approach, run ./gradlew
:ktor-openapi-schema:updateLegacyAbi and checkLegacyAbi to update ABI signatures
for the new public getter behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In
`@ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt`:
- Around line 77-103: The Default getter currently returns a new
KotlinxSerializerJsonSchemaInference each call which yields ephemeral
kTypeLookup caches (affecting buildSchema, KotlinxJsonSchemaInference and
SerialDescriptor.buildJsonSchema paths) — either make Default return a cached
singleton instance of KotlinxSerializerJsonSchemaInference (so kTypeLookup is
shared) or explicitly document the intended per-call scope with a comment; while
here, harden includeKType’s key strategy by using (type.classifier as?
KClass<*>)?.qualifiedName ?: type.toString().substringBefore('<') in
includeKType and ensure isParameterized reads from the intended shared
kTypeLookup; after choosing the approach, run ./gradlew
:ktor-openapi-schema:updateLegacyAbi and checkLegacyAbi to update ABI signatures
for the new public getter behavior.

In
`@ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/src/io/ktor/openapi/reflect/JsonSchemaInference.jvm.kt`:
- Around line 242-250: The code calculates resolvedPropertyType via swapTypeArgs
but still calls buildSchemaOrRef(prop.returnType…), so generic type
substitutions are ignored; change the call to use the substituted type
(resolvedPropertyType) and use that same resolved type when checking nullability
and when passing annotations/context into buildSchemaInternal (i.e., replace
occurrences of prop.returnType with resolvedPropertyType for schema creation),
ensuring functions like buildSchemaOrRef, buildSchemaInternal, swapTypeArgs, and
adapter.isNullable operate on the resolvedPropertyType so Reflection produces
the correct nested schema for generic properties (e.g., Response<T>.data).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d2751bab-a6f0-4f39-ae4d-dfe6e46ba7ac

📥 Commits

Reviewing files that changed from the base of the PR and between f4abce9 and f7eb788.

📒 Files selected for processing (4)
  • ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt
  • ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/src/io/ktor/openapi/reflect/JsonSchemaInference.jvm.kt
  • ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test-resources/schema/Response.yaml
  • ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test/io/ktor/openapi/reflect/AbstractSchemaInferenceTest.kt

Copy link
Copy Markdown
Member

@osipxd osipxd left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@bjhham bjhham enabled auto-merge (squash) April 20, 2026 13:26
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt (1)

80-100: ⚠️ Potential issue | 🟠 Major

Shared Default instance: kTypeLookup is unsynchronized, unbounded, and drops sibling type args.

KotlinxSerializerJsonSchemaInference.Default (Lines 77-78) is a process-wide singleton, and kTypeLookup is a plain mutableMapOf that is written from buildSchema/includeKType without synchronization and never cleared. Three concrete concerns:

  1. Thread-safety: concurrent buildSchema(...) calls on Default (the normal case when used via KotlinxJsonSchemaInference) race on the same map and can raise ConcurrentModificationException or produce corrupt reads.
  2. Unbounded growth: every distinct type ever inferred stays resident for the lifetime of the process — effectively a leak for long-running servers.
  3. Sibling-argument collision: keying on type.toString().substringBefore('<') and early-returning at Line 95 means that for e.g. Pair<List<Int>, List<String>>, the first List<…> caches "kotlin.collections.List" and the recursion into the second List<String> is skipped entirely — so String's KType is never registered. This directly undermines the PR's stated goal of "populating a lookup of KTypes so nested generic information is available for schema inference" as soon as the same generic container appears twice with different arguments (and the JVM-side swapTypeArgs consults this information).

Within this file isParameterized() only reads arguments.isNotEmpty(), so the local title-suppression still works; the failure mode is for any consumer (including the JVM inference added in this PR) that looks up a specific argument type.

💡 Suggested direction
  • Scope the lookup to a single buildSchema call (e.g., pass it alongside visiting) so it is inherently per-invocation and thread-local; or
  • If a shared cache really is desired, use ConcurrentHashMap/a synchronized wrapper and key by the full KType (or type.toString()) so distinct parameterizations don't collide, and recurse unconditionally into each typeArg.type regardless of whether the parent name was seen before.
-    private val kTypeLookup = mutableMapOf<String, KType>()
-
-    override fun buildSchema(type: KType): JsonSchema {
-        includeKType(type)
-        return buildSchemaFromDescriptor(
-            module.serializer(type).descriptor,
-            // parameterized types cannot be referenced from their serial name
-            includeTitle = type.arguments.isEmpty(),
-            visiting = mutableSetOf()
-        )
-    }
-
-    private fun includeKType(type: KType) {
-        // use toString() because qualifiedName is unavailable in web
-        val qualifiedName = type.toString().substringBefore('<')
-        if (qualifiedName in kTypeLookup) return
-        kTypeLookup[qualifiedName] = type
-        for (typeArg in type.arguments) {
-            typeArg.type?.let(::includeKType)
-        }
-    }
+    override fun buildSchema(type: KType): JsonSchema {
+        val kTypeLookup = mutableMapOf<String, KType>()
+        includeKType(type, kTypeLookup)
+        return buildSchemaFromDescriptor(
+            module.serializer(type).descriptor,
+            // parameterized types cannot be referenced from their serial name
+            includeTitle = type.arguments.isEmpty(),
+            visiting = mutableSetOf(),
+            kTypeLookup = kTypeLookup,
+        )
+    }
+
+    private fun includeKType(type: KType, lookup: MutableMap<String, KType>) {
+        // use toString() because qualifiedName is unavailable in web
+        val key = type.toString()
+        if (lookup.put(key, type) != null) return
+        for (typeArg in type.arguments) {
+            typeArg.type?.let { includeKType(it, lookup) }
+        }
+    }

This also requires threading kTypeLookup through buildSchemaFromDescriptor / isParameterized() rather than reading a field.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt`
around lines 80 - 100, The shared mutable kTypeLookup causes races, leaks, and
generic-argument collisions; make the lookup be per-invocation (thread-local) by
creating a new map inside buildSchema and passing it through to includeKType and
buildSchemaFromDescriptor/ isParameterized (i.e., thread-scope the map alongside
visiting) so each buildSchema call uses its own lookup and is cleared when done;
ensure includeKType keys by the full type string (type.toString()) and do not
early-return skipping recursion into other type arguments — always recurse into
each typeArg.type to register distinct parameterizations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt`:
- Around line 102-103: isParameterized() relies on nonNullSerialName while
kTypeLookup was populated using KType.toString().substringBefore('<'), creating
mismatched keys; fix by normalizing the lookup so both sides use the same key
strategy: either change the lookup population to store mappings by serial
descriptor name (e.g., use SerialDescriptor.serialName.substringBefore('<') or a
normalizedSerialName utility) or change kTypeLookup to map KType →
SerialDescriptor directly, and update usages in isParameterized(), any callers
referencing kTypeLookup, and related logic (nonNullSerialName, includeTitle,
LIST/MAP branches) so keys are consistent and no fragile coupling remains.

---

Outside diff comments:
In
`@ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt`:
- Around line 80-100: The shared mutable kTypeLookup causes races, leaks, and
generic-argument collisions; make the lookup be per-invocation (thread-local) by
creating a new map inside buildSchema and passing it through to includeKType and
buildSchemaFromDescriptor/ isParameterized (i.e., thread-scope the map alongside
visiting) so each buildSchema call uses its own lookup and is cleared when done;
ensure includeKType keys by the full type string (type.toString()) and do not
early-return skipping recursion into other type arguments — always recurse into
each typeArg.type to register distinct parameterizations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b67a59f5-64c0-4c71-8eee-1ffc09a85ead

📥 Commits

Reviewing files that changed from the base of the PR and between f7eb788 and 4cc537e.

📒 Files selected for processing (2)
  • ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt
  • ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/src/io/ktor/openapi/reflect/JsonSchemaInference.jvm.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/src/io/ktor/openapi/reflect/JsonSchemaInference.jvm.kt

Comment on lines +102 to +103
private fun SerialDescriptor.isParameterized(): Boolean =
kTypeLookup[nonNullSerialName]?.arguments?.isNotEmpty() == true
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify serialName vs KType.toString() for built-in containers used via module.serializer(type).descriptor
rg -nP --type=kt -C2 '\bnonNullSerialName\b|isParameterized\b' ktor-shared/ktor-openapi-schema
rg -nP --type=kt -C3 'kTypeLookup' ktor-shared/ktor-openapi-schema

Repository: ktorio/ktor

Length of output: 108


🏁 Script executed:

#!/bin/bash
# Search for nonNullSerialName, isParameterized, and kTypeLookup without -t flag
rg -nP -C2 'nonNullSerialName|isParameterized' ktor-shared/ktor-openapi-schema
echo "---"
rg -nP -C3 'kTypeLookup' ktor-shared/ktor-openapi-schema

Repository: ktorio/ktor

Length of output: 10316


🏁 Script executed:

#!/bin/bash
# Search for all calls to isParameterized
rg -n 'isParameterized' ktor-shared/ktor-openapi-schema

# Check the LIST and MAP handling sections to see if they use includeTitle
sed -n '205,235p' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt

Repository: ktorio/ktor

Length of output: 1670


🏁 Script executed:

#!/bin/bash
# Check the CLASS/OBJECT branch to see how includeTitle is used
sed -n '133,170p' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt

# Search for tests that exercise this code path with List/Map types
find ktor-shared/ktor-openapi-schema -name '*Test.kt' -o -name '*Spec.kt' | head -5

# Look for actual serialNames of ArrayList/HashMap
rg -n 'ArrayList|HashMap' ktor-shared/ktor-openapi-schema --type=kt | head -20

Repository: ktorio/ktor

Length of output: 2248


🏁 Script executed:

#!/bin/bash
# Look at test files to see what types are exercised
ls -la ktor-shared/ktor-openapi-schema/*/test/io/ktor/openapi/

# Check one of the test files for List/Map examples
head -100 ktor-shared/ktor-openapi-schema/jvm/test/io/ktor/openapi/GenericElementTest.kt

Repository: ktorio/ktor

Length of output: 3503


🏁 Script executed:

#!/bin/bash
# Check the reflection test which likely exercises List/Map types
head -150 ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test/io/ktor/openapi/reflect/KotlinxJsonSchemaInferenceTest.kt

# Also search for List and Map in test files
rg -n 'List<|Map<' ktor-shared/ktor-openapi-schema --glob='*Test.kt' | head -20

Repository: ktorio/ktor

Length of output: 1872


🏁 Script executed:

#!/bin/bash
# Look at the AbstractSchemaInferenceTest to see what List/Map tests are doing
sed -n '70,100p' ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test/io/ktor/openapi/reflect/AbstractSchemaInferenceTest.kt

# Also check how the buildSchema method handles these types
sed -n '82,100p' ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test/io/ktor/openapi/reflect/AbstractSchemaInferenceTest.kt

Repository: ktorio/ktor

Length of output: 2019


🏁 Script executed:

#!/bin/bash
# Let's trace what happens when we get the descriptor for List<Address>
# First, check if there's any code that shows what descriptor.kind returns for ArrayList/HashMap

# Search for how descriptors are obtained or what their serialNames might be
rg -n 'serializer.*List|serializer.*Map' ktor-shared/ktor-openapi-schema --glob='*.kt' | head -10

# Check if there are any direct descriptor references
rg -n 'ArrayList|HashMap|descriptor\.kind' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt

Repository: ktorio/ktor

Length of output: 919


🏁 Script executed:

#!/bin/bash
# Search for buildSchemaOrReference implementation
rg -n 'fun buildSchemaOrReference' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt -A 10

Repository: ktorio/ktor

Length of output: 520


🏁 Script executed:

#!/bin/bash
# Find the full buildSchema implementation
sed -n '82,92p' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt

# And check buildSchemaOrReference complete implementation
sed -n '298,320p' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt

Repository: ktorio/ktor

Length of output: 1230


🏁 Script executed:

#!/bin/bash
# Let's verify: what is the actual key used for List types when includeKType is called?
# And understand the full picture

# Get the full file context around buildSchema and includeKType
sed -n '82,130p' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt

# Also check if isParameterized is called anywhere else besides line 108
rg -n 'isParameterized\(\)' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt

Repository: ktorio/ktor

Length of output: 2181


🏁 Script executed:

#!/bin/bash
# Now let's verify the actual serialNames by checking what happens with List/Map in real cases
# Search for where descriptors for built-in types might come from

# Check the isContainerType function to understand how List/Map are handled
rg -n 'isContainerType' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt -A 5

Repository: ktorio/ktor

Length of output: 540


🏁 Script executed:

#!/bin/bash
# Let's understand: when we have List<Address>, what is the descriptor's serialName?
# Create a simple test to check this

python3 << 'EOF'
# Looking at the logic:
# 1. buildSchema(List<Address>) calls:
#    - includeKType(List<Address>) → stores key "kotlin.collections.List" in kTypeLookup
#    - module.serializer(List<Address>).descriptor → gets descriptor with kind == StructureKind.LIST
#    - The descriptor's serialName for ArrayList would be "kotlin.collections.ArrayList"
#
# 2. In buildSchemaFromDescriptor, when it hits the LIST branch:
#    - It calls buildSchemaOrReference for item descriptor without explicit includeTitle
#    - So includeTitle defaults to !descriptor.isParameterized()
#    - isParameterized() looks up nonNullSerialName in kTypeLookup
#    - But if the item descriptor is also a container (e.g., List<String>), 
#      its nonNullSerialName would be "kotlin.collections.ArrayList" or similar
#    - But kTypeLookup has "kotlin.collections.List"
#    - Lookup fails, returns false
#
# The claim is that this is a key mismatch that will cause wrong behavior

# Let me verify if buildSchemaOrReference can be called with container descriptors
print("Analysis:")
print("1. buildSchema() stores type keys as KType.toString().substringBefore('<')")
print("   Example: List<Address> → 'kotlin.collections.List'")
print("")
print("2. isParameterized() looks up using descriptor.nonNullSerialName")
print("   For ArrayList descriptor → 'kotlin.collections.ArrayList'")
print("")
print("3. These keys don't match → lookup fails")
print("")
print("4. buildSchemaOrReference calls buildSchemaFromDescriptor without includeTitle param")
print("   So default is used: !descriptor.isParameterized()")
print("")
print("5. For LIST/MAP descriptors, includeTitle is ignored anyway")
print("6. But for CLASS/OBJECT descriptors passed as element type, it would matter")
EOF

Repository: ktorio/ktor

Length of output: 638


🏁 Script executed:

#!/bin/bash
# Let's verify: can isParameterized() actually affect real code paths?
# Search for buildSchemaOrReference calls to see what descriptors are passed

sed -n '298,320p' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt

# And check the signature of buildSchemaOrReference - does it ever pass includeTitle explicitly?
rg -n 'buildSchemaOrReference' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt

# Check if buildSchemaOrReference can receive container descriptors
sed -n '140,160p' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt

Repository: ktorio/ktor

Length of output: 2061


🏁 Script executed:

#!/bin/bash
# Final verification: check if isParameterized is only used in the default parameter
# or if there are other calls to it that might be affected

rg -n 'isParameterized' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt

# Also verify: what are the actual serialNames for ArrayList and HashMap?
# by checking kotlinx-serialization built-in descriptors
cat > /tmp/test_serialnames.kt << 'KOTLIN'
import kotlinx.serialization.serializer
import kotlin.reflect.typeOf

fun main() {
    val listType = typeOf<List<String>>()
    val mapType = typeOf<Map<String, String>>()
    
    val listDescriptor = serializer(listType).descriptor
    val mapDescriptor = serializer(mapType).descriptor
    
    println("List descriptor serialName: ${listDescriptor.serialName}")
    println("List descriptor kind: ${listDescriptor.kind}")
    println("Map descriptor serialName: ${mapDescriptor.serialName}")
    println("Map descriptor kind: ${mapDescriptor.kind}")
}
KOTLIN

# Check if we can find any hint in the code about what these serialNames are
rg -n 'kotlin.collections' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt

Repository: ktorio/ktor

Length of output: 187


isParameterized() uses mismatched lookup keys for built-in container descriptors, creating fragile coupling.

isParameterized() looks up descriptors by nonNullSerialName (e.g., "kotlin.collections.ArrayList"), but kTypeLookup stores built-in types by their KType.toString().substringBefore('<') keys (e.g., "kotlin.collections.List"). While this currently has minimal impact—includeTitle only affects CLASS/OBJECT kinds and LIST/MAP branches ignore it—the mismatch creates fragile coupling. Future callers trusting isParameterized() on element descriptors or changes to includeTitle usage could yield incorrect results.

Recommend either documenting the "class/object serial names only" contract explicitly in code comments, or normalizing both sides by storing KType → SerialDescriptor mappings directly instead of deriving keys separately.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt`
around lines 102 - 103, isParameterized() relies on nonNullSerialName while
kTypeLookup was populated using KType.toString().substringBefore('<'), creating
mismatched keys; fix by normalizing the lookup so both sides use the same key
strategy: either change the lookup population to store mappings by serial
descriptor name (e.g., use SerialDescriptor.serialName.substringBefore('<') or a
normalizedSerialName utility) or change kTypeLookup to map KType →
SerialDescriptor directly, and update usages in isParameterized(), any callers
referencing kTypeLookup, and related logic (nonNullSerialName, includeTitle,
LIST/MAP branches) so keys are consistent and no fragile coupling remains.

@bjhham bjhham merged commit 1f83f21 into release/3.x Apr 20, 2026
17 of 19 checks passed
@bjhham bjhham deleted the bjhham/nested-generics-schema branch April 20, 2026 13:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants